package com.panopset.flywheel;

import static com.panopset.Util.*;

import java.io.File;
import java.io.IOException;
import java.io.StringWriter;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import java.util.Stack;
import java.util.StringTokenizer;
import java.util.Vector;

import com.panopset.MapProvider;
import com.panopset.StringLineSupplier;
import com.panopset.Util;
import com.panopset.flywheel.gui.Gui;
import com.panopset.io.FileProcessor;
import com.panopset.map.KeyReporter;
import com.panopset.map.NamedMap;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Panopset Flywheel. <h3>Commands</h3> There are 7 commands that you may use in
 * a Flywheel template. Commands have a default syntax that starts with
 * <b>${&#064;</b>, followed by the letter associated with the command, followed
 * by a space, followed by a parameter, followed by the default closing syntax
 * of <b>}</b>. <h5>f - File</h5>
 *
 * <pre>
 * ${&amp;#064f somefile.txt}
 * </pre>
 *
 * Output to the specified file, until the matching ${&#064q} is found. <h5>p -
 * Push</h5>
 *
 * <pre>
 *
 * ${&amp;#064p variableName}
 * </pre>
 *
 * Everything following this command is pushed into a String buffer, until a q
 * command is reached. <h5>l - List</h5>
 *
 * <pre>
 * ${&amp;#064l someListFile.txt} or ${&amp;#064l someFile.txt&tilde;,}
 * </pre>
 *
 * Read the given file, and for each line execute the template from this list
 * command up until its matching q command. If no <b>token</b> is defined as a
 * variable, then each line will be stored in variable <b>1</b>. If there is a
 * token defined after a ~, the line will be split by that token, and stored in
 * variables named after integers, in order.
 *
 * <h5>q - Quit</h5>
 *
 * <pre>
 * ${&amp;#064q}
 * </pre>
 * <ul>
 * <li>If the q command follows a p command, the String buffer is defined as the
 * variable name that was provided in the p command.</li>
 * <li>If the q command follows an l command, the String buffer is read for the
 * each item in the list.</li>
 * </ul>
 * <h5>t - Template</h5>
 *
 * <pre>
 * ${&amp;#064t someTemplateFile.txt}
 * </pre>
 *
 * Continue execution using the supplied template script file. <h5>Variable</h5>
 * There is no command associated with a variable, so you drop the <b>&#064</b>
 * directive indicator, and then you specify a variable just as you would in any
 * ant script or unix shell. The variable must have been defined either in a map
 * provided to the script through Script.Builder.mergeMap, or a Push command.
 *
 * <pre>
 * ${variableName}
 * </pre>
 *
 * <h5>e - Execute</h5> Excecute any static method. Parameters may be variable
 * names only. Example:
 *
 * <pre>
 *
 * ${&amp;#064p name}panopset${q}
 *
 * ${&amp;#064e com.panopset.Util.capitalize(name)}
 *
 * </pre>
 *
 * The above script will output:
 *
 * <pre>
 *
 * Panopset
 *
 * </pre>
 *
 * @author Karl Dinwiddie
 */
public class Flywheel implements MapProvider, ReservedWords {

    public boolean isReplacementsSuppressed = false;
    public final String directive;
    public final String openDirective;
    public final String closeDirective;
    public File targetDirectory;
    private File file;
    private final StringLineSupplier sls;
    public Command resolvingCommand;
    public boolean isStepping = false;// TODO: does not belong here
    public boolean isWaiting = false;
    private StringWriter writer;
    private boolean stop = false;
    protected final Stack<NamedMap<String, String>> mapStack = new Stack<NamedMap<String, String>>();
    protected CommandFile currentCommandFile;
    private Vector<String[]> replacements;
    protected Map<String, Object> registeredObjects;
    protected Vector<File> reportTemplates;
    public Report report;
    public String reports;
    public String reportsHomeLinkTitle;
    public String reportsHref;
    private File reportsDirectory;

    /**
     * @param args
     *            <pre>
     *            [0] = script file name, args[1] = target directory name.
     *              or
     *            [0] = properties file name where these properties are required:
     *                      com.panopset.flywheel.script=&lt;your script file name&gt;
     * com.panopset.flywheel.target=&lt;your target file name&gt;
     *
     *            <pre>
     */
    public static void main(String... args) {
        try {
            if (args == null || args.length == 0 || args.length > 2) {
                new Gui().setVisible(true);
            } else {
                Flywheel flywheel = (args.length == 1) ? new Flywheel.Builder()
                        .properties(new File(args[0])).construct()
                        : new Flywheel.Builder().file(new File(args[0]))
                                .targetDirectory(new File(args[1])).construct();
                flywheel.exec();
            }
        } catch (RuntimeException ex) {
            log(ex);
        }
    }

    /**
     * Copy package resource.
     *
     * @param resourcePath
     *            Java source path resource.
     * @param targetPath
     *            Target path, relative to Flywheel target directory.
     */
    public void copyPackageResource(String resourcePath, String targetPath)
            throws IOException {
        try {
            com.panopset.Util.copyLibraryResource(resourcePath,
                    getCanonicalPath(targetDirectory) + PATH_SEP + targetPath);
        } catch (IOException ex) {
            log("baseDirectoryPath: " + baseDirectoryPath);
            log("Template file: " + getCanonicalPath(file));
            log("targetDirectory: " + targetDirectory);
            log("resourcePath: " + resourcePath);
            log("targetPath: " + targetPath);
            if (template != null) {
                if (template.sls != null) {
                    log("executing from: " + template.sls.getFullName());
                    log("Line: " + template.sls.line);
                }
            }
            throw (ex);
        }
    }

    /**
     * Flywheel constructor.
     *
     * @param String
     *            line supplier.
     * @param directive
     *            Syntax for directive, usually <b>&amp;</b>.
     * @param openDirective
     *            Syntax for openDirective, usually <b>${</b>.
     * @param closeDirective
     *            Syntax for closeDirective, usually <b>}</b>.
     */
    private Flywheel(StringLineSupplier sls, String directive,
            String openDirective, String closeDirective) {
        this.sls = sls;
        this.directive = directive;
        this.openDirective = openDirective;
        this.closeDirective = closeDirective;
        this.mapStack.push(new NamedMap<String, String>(generateUniqueKey()));
        this.mapStack.peek().put(FILE, sls.getName());
    }

    /**
     * Is target directory valid.
     *
     * @return true if a valid, writable target directory has been specified.
     */
    public boolean isTargetDirectoryValid() {
        if (targetDirectory != null) {
            if (!targetDirectory.exists()) {
                mkdirs(targetDirectory);
            }
        }
        return targetDirectory != null && targetDirectory.isDirectory()
                && targetDirectory.exists() && targetDirectory.canWrite();
    }

    /**
     * This method should only be used when a writer was specified in the
     * builder. In other words, when you are using the templates to generate a
     * text String, instead of files, then you would use this method to get the
     * output.
     *
     * @return StringWriter holds script output.
     */
    public StringWriter getWriter() {
        if (writer == null) {
            writer = new StringWriter();
        }
        return writer;
    }

    /**
     *
     * @param writer
     */
    public void setWriter(StringWriter writer) {
        this.writer = writer;
    }

    /**
     * Put map value, that will be available to future v commands.
     *
     * @param key
     *            Variable name.
     * @param value
     *            Variable value.
     */
    public void put(String key, String value) {
        mapStack.peek().put(key, value);
    }

    /**
     *Load properties.
     *
     * @param relativePath
     *            Path is relative to the parent of the main Flywheel script
     *            file.
     * @return empty String.
     */
    public String loadProperties(String relativePath) {
        File f = new File(getBaseDirectoryPath() + PATH_SEP + relativePath);
        Properties p = new Properties();
        if (!f.exists()) {
            try {
                Util.copyLibraryResource(relativePath, f);
            } catch (IOException ex) {
                log("relativePath: " + relativePath);
                Logger.getLogger(Flywheel.class.getName()).log(Level.SEVERE,
                        null, ex);
            }
        }
        Util.loadProperties(p, f);
        for (Object k : p.keySet()) {
            put(k.toString(), p.getProperty(k.toString()).toString());
        }
        return "";
    }

    /**
     * <b>Usage</b>
     *
     * <pre>
     * ${&amp;#064p foo}bar{&amp;#064q}
     * ${&amp;#064e script.suppressReplacements(true)}
     * A: ${&amp;#064v foo}
     * ${&amp;#064e script.suppressReplacements(false)}
     * B: ${&amp;#064v foo}
     * </pre>
     *
     * <b>Output</b>
     *
     * <pre>
     *
     * A:
     * B: bar
     *
     * </pre>
     *
     * @param value
     *            If true, all v commands will be not output anything.
     */
    public void suppressReplacements(String value) {
        if (value == null) {
            isReplacementsSuppressed = false;
            return;
        }
        isReplacementsSuppressed = Boolean.parseBoolean(value);
    }

    /**
     * Get map value.
     *
     * @param key
     *            Variable name.
     * @return value or blank if key not found.
     */
    @Override
    public String get(String key) {
        if (mapStack.isEmpty()) {
            stop("mapStack empty, this should be an impossible condition.");
            return null;
        }
        Stack<NamedMap<String, String>> stack = new Stack<NamedMap<String, String>>();
        for (NamedMap<String, String> item : mapStack) {
            stack.push(item);
        }
        NamedMap<String, String> m = stack.pop();
        while (true) {
            String rtn = m.get(key);
            if (rtn != null) {
                return rtn;
            }
            if (stack.isEmpty()) {
                return null;
            } else {
                m = stack.pop();
            }
        }
    }

    /**
     * Merge a map into the script map. Existing values are overwritten.
     *
     * @param map
     *            Map to merge into the script map.
     */
    public void mergeMap(Map<String, String> map) {
        mapStack.push(new NamedMap<String, String>(generateUniqueKey(), map));
    }

    void reportSourceFile(SourceFile sourceFile) {
        if (report != null) {
            report.addSourceFile(sourceFile);
        }
    }

    boolean hasReports;

    /**
     * Execute this script.
     *
     * @return results, for unit tests and server side operations.
     */
    public String exec() {
        String rtn = "";
        try {
            hasReports = reportsDirectory == null && reports != null
                    && targetDirectory != null;
            if (hasReports) {
                reportsDirectory = new File(getCanonicalPath(targetDirectory)
                        + PATH_SEP + reports);
            }
            if (reportsDirectory != null) {
                mkdirs(reportsDirectory);
                if (reportsDirectory != null && reportsDirectory.exists()
                        && reportsDirectory.isDirectory()) {
                    report = new Report(this, reportsDirectory);
                    if (file != null) {
                        new SourceFile(this, file);
                    }
                }
            }
            getTemplate().output(getWriter());
            rtn = getWriter().toString();
            if (report != null) {
                report.exec();
            }
            if (hasReports) {
                KeyReporter.singleton
                        .reportUnusedKeys(getCanonicalPath(reportsDirectory)
                                + PATH_SEP + "unusedkeys.txt");
            }
        } catch (Throwable t) {
            log(t);
            stop(t.getMessage());
        }
        return rtn;
    }

    private Template template;

    public Template getTemplate() {
        if (template == null) {
            template = new Template(this, sls);
        }
        return template;
    }

    /**
     * Called if something unexpected happens. Diagnostic messages are logged,
     * in addition to the message.
     *
     * @param message
     *            Log this message
     */
    public void stop(String message) {
        log(message);
        log(x("Unexpected stop, dumping top map."));
        if (template != null) {
            log(template.getRelativePath());
        }
        dump(mapStack.peek());
        if (resolvingCommand != null) {
            log(x("Stopped while executing line") + SINGLE_SPACE
                    + resolvingCommand.lineNumber + resolvingCommand.toString());
        }
        stop = true;
    }

    /**
     * Used by debuggers.
     *
     * @return true if the stop method was called for any reason, usually some
     *         error condition.
     */
    public boolean isStopped() {
        return stop;
    }

    private String baseDirectoryPath;

    /**
     * Get the directory in which the script top template file resides. This is
     * used as a relative path base for all subsequent template files created
     * with the template command: <b>${&#064;t templateRelativePathName}</b>.
     *
     * @return Full path of parent directory of the top script template file.
     */
    public String getBaseDirectoryPath() {
        if (baseDirectoryPath == null) {
            baseDirectoryPath = System.getProperty("user.home");
        }
        return baseDirectoryPath;
    }

    void setReplacements(Vector<String[]> replacementsVector) {
        this.replacements = replacementsVector;
    }

    protected Vector<String[]> getReplacements() {
        if (replacements == null) {
            replacements = new Vector<String[]>();
        }
        return replacements;
    }

    /**
     * Append simple file name to getBaseDirectoryPath
     *
     * @param simpleFileName
     * @return Full relative path of simpleFileName
     */
    public String getBaseRelativePath(String simpleFileName) {
        return getBaseDirectoryPath() + FORWARD_SLASH + simpleFileName;
    }

    /**
     * Script builder
     */
    public static class Builder {

        private StringWriter writer;
        private String directive;
        private File reportsDirectory;
        private String reports;
        private String reportsHomeLinkTitle;
        private String reportsHref;
        private String openDirective;
        private String closeDirective;
        private File targetDirectory;
        private String[] array;
        private StringLineSupplier sls;
        private File file;
        private String baseDirectoryPath;
        private boolean verbose;
        private Map<String, String> map = new HashMap<String, String>();
        private Map<String, Object> registeredObjects = new HashMap<String, Object>();

        /**
         * Create a script. Example usage:
         *
         * <pre>
         * new Script.Builder().targetDirectory(&quot;publish/site&quot;).scriptFile(
         *         &quot;templates/index.txt&quot;).properties(&quot;my.properties&quot;) // Stored as Script variables.
         *         .constructScript().exec();
         * </pre>
         *
         * @return Panopset Flywheel Script object.
         */
        public Flywheel construct() {
            String scriptFileName = this.map.get(SCRIPT);
            String targetDirName = this.map.get(TARGET);

            if (isPopulated(scriptFileName)) {
                this.file = new File(scriptFileName);
            }
            if (this.file == null) {
                if (this.array == null) {
                    this.file = new File("com.panopset.flywheel.EmptyFile.txt");
                    this.sls = new StringLineSupplier(this.file);
                } else {
                    this.sls = new StringLineSupplier(this.array);
                }
            } else {
                this.sls = new StringLineSupplier(this.file);
            }
            Flywheel flywheel = new Flywheel(sls, getDirective(),
                    getOpenDirective(), getCloseDirective());
            if (this.baseDirectoryPath != null) {
                flywheel.baseDirectoryPath = this.baseDirectoryPath;
            }
            if (this.file != null && flywheel.baseDirectoryPath == null) {
                flywheel.baseDirectoryPath = getParentDirectory(this.file);
            }
            flywheel.mergeMap(this.map);
            flywheel.setReplacements(replacements);
            if (isPopulated(targetDirName)) {
                flywheel.targetDirectory = new File(targetDirName);
            }
            if (flywheel.targetDirectory == null) {
                flywheel.targetDirectory = getTargetDirectory();
            }
            flywheel.writer = writer;
            registeredObjects.put(ReservedWords.FLYWHEEL, flywheel);
            flywheel.registeredObjects = registeredObjects;
            if (reportsDirectory == null) {
                flywheel.reports = reports;
            } else {
                flywheel.reportsDirectory = reportsDirectory;
            }
            Util.verbose = verbose;
            if (this.file != null) {
                flywheel.file = this.file;
            }
            flywheel.reportsHomeLinkTitle = reportsHomeLinkTitle;
            flywheel.reportsHref = reportsHref;
            return flywheel;
        }

        /**
         * Set target directory. This is where your output is going. Directory
         * structures will be created as needed, and <b>existing files are wiped
         * without warning</b>.
         *
         * @param targetDirectory
         *            All output files specified by the ${&#064f fileName}
         *            command are relative paths based here.
         * @return Builder.
         */
        public Builder targetDirectory(File targetDirectory) {
            this.targetDirectory = targetDirectory;
            return this;
        }

        /**
         * Set script file.
         *
         * @param scriptFile
         *            Specify a single controlling script file.
         * @return Builder.
         */
        public Builder file(File file) {
            this.file = file;
            return this;
        }

        /**
         * You do not need to specify this if you are specifying a File in
         * Flywheel.Builder.file, as the parent directory of the main template
         * file would be used. This method would rarely be needed, but is
         * available for flexibility.
         *
         * @param path
         * @return Builder.
         */
        public Builder baseDirectoryPath(String path) {
            this.baseDirectoryPath = path;
            return this;
        }

        /**
         * Place html reports on your template structure in this directory. Use
         * reports(File reportsDirectory) if this is for the server side.
         *
         * @param reports
         *            - relative path to targetDirectory
         * @return Builder.
         */
        public Builder reports(String reports) {
            this.reports = reports;
            return this;
        }

        /**
         * If you want reports placed in a directory outside the target
         * directory, or if there is no target directory, as would be the case
         * on the server side, you may specify a reports directory here. An
         * error will be thrown if the reportsDirectory file exists and is not a
         * directory.
         *
         * @param reports
         * @return Builder.
         */
        public Builder reports(File reportsDirectory) throws IOException {
            if (reportsDirectory.exists() && reportsDirectory.isFile()) {
                throw new IOException("Will not overwrite file "
                        + getCanonicalPath(reportsDirectory));
            }
            this.reportsDirectory = reportsDirectory;
            return this;
        }

        /**
         * Rarely used, normally you'd want the default of <b>&#064;</b>
         *
         * @param directive
         *            Alternative directive.
         * @return Builder.
         */
        public Builder directive(String directive) {
            this.directive = directive;
            return this;
        }

        /**
         * Rarely used, normally you'd want the default of <b>${</b>.
         *
         * @param openDirective
         *            Alternative directive open.
         * @return Builder.
         */
        public Builder openDirective(String openDirective) {
            this.openDirective = openDirective;
            return this;
        }

        /**
         * Rarely used, normally you'd want the default of <b>}</b>.
         *
         * @param closeDirective
         *            Alternative directive close.
         * @return Builder.
         */
        public Builder closeDirective(String closeDirective) {
            this.closeDirective = closeDirective;
            return this;
        }

        /**
         * Set to true if you want verbose messages.
         *
         * @param verbose
         *            Normally you'd leave this to the default of false.
         * @return Builder.
         */
        public Builder verbose(boolean verbose) {
            this.verbose = verbose;
            return this;
        }

        /**
         * If you supply a String array here, do not use the file
         * Flywheel.Builder method.
         *
         * @param string
         * @return
         */
        public Builder input(String[] array) {
            this.array = array;
            return this;
        }

        /**
         * Defaults to new File("temp");
         *
         * @return target directory File.
         */
        private File getTargetDirectory() {
            if (targetDirectory == null) {
                return new File("temp");
            }
            return targetDirectory;
        }

        private String getDirective() {
            if (directive == null) {
                directive = DEFAULT_DIRECTIVE;
            }
            return directive;
        }

        private String getOpenDirective() {
            if (openDirective == null) {
                openDirective = DEFAULT_OPEN_DIRECTIVE;
            }
            return openDirective;
        }

        private String getCloseDirective() {
            if (closeDirective == null) {
                closeDirective = DEFAULT_CLOSE_DIRECTIVE;
            }
            return closeDirective;
        }

        /**
         * You may pre-define variables by supplying a map. Please remember to
         * stay away from the the numbers, as they are used in lists.
         *
         * @param map
         * @return Builder.
         */
        public Builder map(Map<String, String> map) {
            com.panopset.Util.copyMap(map, this.map);
            return this;
        }

        /**
         * Add a key value pair to the map.
         *
         * @param key
         * @param value
         * @return Builder.
         */
        public Builder map(String key, String value) {
            this.map.put(key, value);
            return this;
        }

        /**
         * Specify list line tokens, default is none ("").
         *
         * @param tokens
         * @return Builder.
         */
        public Builder tokens(String tokens) {
            this.map.put(TOKENS, tokens);
            return this;
        }

        /**
         * Register any instantiated objects you want available to the Execute
         * command here. You will be able to use any method of those objects
         * that accept 0 or more String parameters, and that return a single
         * String result. Recommended, but not required, to qualify objects with
         * their path names. com.panopset.flywheel.Flywheel is registered
         * automatically.
         *
         * @param key
         *            Name object will be known by, in Execute commands.
         * @param obj
         *            Instance Execute commands will invoke methods on.
         * @return Builder.
         */
        public Builder registerObject(String key, Object obj) {
            registeredObjects.put(key, obj);
            return this;
        }

        /**
         * Load properties file into pre-defined variables.
         *
         * @see Flywheel.Builder#map
         * @param propertiesFile
         * @return Builder.
         */
        public Builder properties(File propertiesFile) {
            if (propertiesFile != null && propertiesFile.exists()) {
                return map(loadPropsFromFile(propertiesFile));
            } else {
                if (propertiesFile != null) {
                    log(getCanonicalPath(propertiesFile) + SINGLE_SPACE
                            + x("does not exist, skipping."));
                }
                return this;
            }
        }

        public Builder properties(Properties properties) {
            return map(loadMapFromProperties(properties));
        }

        /**
         * Define replacements in a file specified by the replacements
         * parameter. First line of that file contains a single character that
         * is the separator for subsequent lines that define replacements.
         *
         * @param replacements
         *            file.
         * @return Builder.
         */
        public Builder replacements(File replacements) {
            final Vector<String[]> v = new Vector<String[]>();
            new FileProcessor.Builder().listener(new FileProcessor.Listener() {

                String separator;

                @Override
                public void finish(FileProcessor processor) {
                    // nothing to do here
                }

                @Override
                public void process(FileProcessor processor, String[] s) {
                    if (separator == null) {
                        separator = s[0];
                    } else {
                        StringTokenizer st = new StringTokenizer(s[0],
                                separator);
                        v.add(new String[] { st.nextToken(), st.nextToken() });
                    }
                }
            }).file(replacements).construct().exec();
            for (String[] r : v) {
                replacement(r);
            }
            return this;
        }

        /**
         * Add a global replacement. Replacements are performed in the order
         * added.
         *
         * @param s
         *            s[0] = from s[1] = to.
         * @return Builder.
         */
        public Builder replacement(String[] s) {
            return replacement(s[0], s[1]);
        }

        /**
         * Add a global replacement. Replacements are performed in the order
         * added.
         *
         * @param from
         *            String to replace with the to String.
         * @param to
         *            String to put in place of the from String.
         * @return Builder.
         */
        public Builder replacement(String from, String to) {
            getReplacements().add(new String[] { from, to });
            return this;
        }

        Vector<String[]> replacements;

        public Vector<String[]> getReplacements() {
            if (replacements == null) {
                replacements = new Vector<String[]>();
            }
            return replacements;
        }

        /**
         * If you specify a writer, you need not specify a targetDirectory, as
         * it would be meaningless, and any File commands are ignored, as you
         * are intending your output to be a StringWriter.
         *
         * @param writer
         * @return Builder.
         */
        public Builder writer(StringWriter writer) {
            this.writer = writer;
            return this;
        }

        /**
         * If you want a link back to your site at the top of your reports,
         * supply a title and link here.
         *
         * @param title
         *            Usually "home".
         * @param href
         *            Relative or absolute path. For example ("../index.html").
         *            Don't forget the correct number of "../" entries, based
         *            upon what you had put in the reports path.
         * @see com.panopset.flywheel.Flywheel.Builder#reports
         * @return Builder.
         */
        public Builder reportsHomeLink(String title, String href) {
            this.reportsHomeLinkTitle = title;
            this.reportsHref = href;
            return this;
        }
    }

    void setResolvingCommand(Command resolvingCommand) {
        this.resolvingCommand = resolvingCommand;
    }

    void stepResolve(Command command) {
        setResolvingCommand(command);
        if (isStepping) {
            while (isWaiting) {
                try {
                    Thread.sleep(500);
                } catch (InterruptedException ex) {
                    isWaiting = false;
                }
                if (isStopped()) {
                    isStepping = false;
                    isWaiting = false;
                }
            }
            isWaiting = true;
            setResolvingCommand(null);
        }
    }

}
